package joe;

import java.io.OutputStream;
import java.io.PrintStream;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Map;
import java.util.function.LongConsumer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.lwjgl.glfw.Callbacks;
import org.lwjgl.glfw.GLFWErrorCallback;
import org.lwjgl.opengl.GL;
import org.lwjgl.opengl.GL42C;
import org.lwjgl.opengles.GLES;
import org.lwjgl.opengles.GLES30;
import org.lwjgl.system.Configuration;
import org.lwjgl.system.ThreadLocalUtil;
import org.tinylog.Logger;
import static org.lwjgl.glfw.GLFW.*;
import static org.lwjgl.opengles.GLES20.*;

public class RenderGles3 implements Render {
	static {
		//noinspection EmptyTryBlock
		try (final var _ = glfwSetErrorCallback(GLFWErrorCallback.createPrint(new PrintStream(new OutputStream() {
			@Override
			public void write(int b) {
			}
		}) {
			@Override
			public void print(Object obj) {
				Logger.error("RenderGles3: {}", obj);
			}
		}))) {
		}
		if (!glfwInit())
			throw new AssertionError("glfwInit failed");
	}

	private static final int DEPTH_MAX = 7;
	private static final int[] depthFormatTypes = {
			// internalFormat,            format,             type               @formatter:off
			GLES30.GL_R8,                 GLES30.GL_RED,      GL_UNSIGNED_BYTE,  // 1
			GLES30.GL_RG8,                GLES30.GL_RG,       GL_UNSIGNED_BYTE,  // 2
			GL_RGB,                       GL_RGB,             GL_UNSIGNED_BYTE,  // 3
			GL_RGBA,                      GL_RGBA,            GL_UNSIGNED_BYTE,  // 4
			GL_DEPTH_COMPONENT16,         GL_DEPTH_COMPONENT, GL_UNSIGNED_SHORT, // 5
			GLES30.GL_DEPTH_COMPONENT24,  GL_DEPTH_COMPONENT, GL_UNSIGNED_INT,   // 6
			GLES30.GL_DEPTH_COMPONENT32F, GL_DEPTH_COMPONENT, GL_FLOAT,          // 7
			//@formatter:on
	};

	private long window;
	private int width, height;
	int curProgram;
	final int[] curTexes = new int[32];
	int curSlot, curTex, nilSlot;
	private final int[] uniformsBuf = new int[3 * 64];
	private float depthFactor, depthUnits;
	private boolean cullBack;
	private boolean depthTest;
	private boolean depthMask;
	private boolean depthOffset;
	private boolean blend;

	@Override
	public void init(@NotNull InitParams params) {
		if (window != 0)
			throw new IllegalStateException("already inited");
		try {
			var tmpBuf = Engine.current().getTmpBuf();
			glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
			glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);
			glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_ES_API);
			if ((window = glfwCreateWindow(params.width, params.height, params.title, 0, 0)) == 0)
				throw new AssertionError("glfwCreateWindow failed");
			if (params.fullWindow) {
				var mode = glfwGetVideoMode(glfwGetPrimaryMonitor());
				if (mode != null) {
					glfwSetWindowAttrib(window, GLFW_FLOATING, GLFW_TRUE);
					glfwSetWindowAttrib(window, GLFW_DECORATED, GLFW_FALSE);
					glfwSetWindowPos(window, 0, 0);
					glfwSetWindowSize(window, mode.width(), mode.height());
				} else
					Logger.error("RenderGles3: glfwGetVideoMode failed");
			}
			if (params.keepAspectRatio)
				glfwSetWindowAspectRatio(window, params.width, params.height);
			glfwFocusWindow(window);
			nglfwGetWindowSize(window, tmpBuf.ptr(), tmpBuf.ptr(4));
			width = tmpBuf.getInt(0);
			height = tmpBuf.getInt(1);
			//noinspection EmptyTryBlock
			try (final var _ = glfwSetFramebufferSizeCallback(window, (_, w, h) -> {
				width = w;
				height = h;
			})) {
			}
			glfwMakeContextCurrent(window);
			glfwSwapInterval(params.swapInterval);

			Configuration.OPENGLES_EXPLICIT_INIT.set(true);
			var fp = GL.getFunctionProvider();
			ThreadLocalUtil.setFunctionMissingAddresses(0); // prevent 'setFunctionMissingAddresses has been called already'
			GLES.create(fp);
			GLES.createCapabilities(); // -> GLESCapabilities
//			try {
//				GL43C.glDebugMessageCallback((source, type, id, severity, length, message, _) -> {
//					Logger.error("OpenGL Debug: source={}, type={}, id={}, severity={}, message={}",
//							source, type, id, severity, MemoryUtil.memUTF8(message, length));
//				}, 0);
//			} catch (Exception _) { // ignore
//			}
			glClearColor(params.clearRed, params.clearGreen, params.clearBlue, params.clearAlpha);
			glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
		} catch (Throwable e) { // rethrow
			destroy();
			throw e;
		}
	}

	long getWindow() {
		return window;
	}

	@Override
	public boolean isRunning() {
		return !glfwWindowShouldClose(window);
	}

	@Override
	public void beginRender() {
		glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
	}

	@Override
	public int getWindowWidth() {
		return width;
	}

	@Override
	public int getWindowHeight() {
		return height;
	}

	@Override
	public void setViewport(int x, int y, int width, int height) {
		glViewport(x, y, width, height);
	}

	@Override
	public boolean setCullBack(boolean cullBack) {
		var old = this.cullBack;
		if (old != cullBack) {
			this.cullBack = cullBack;
			if (cullBack) {
				glEnable(GL_CULL_FACE);
				// glCullFace(GL_BACK);
			} else
				glDisable(GL_CULL_FACE);
		}
		return old;
	}

	@Override
	public boolean setDepthTest(boolean depthTest) {
		var old = this.depthTest;
		if (old != depthTest) {
			this.depthTest = depthTest;
			if (depthTest) {
				glEnable(GL_DEPTH_TEST);
				// glDepthFunc(GL_LESS);
			} else
				glDisable(GL_DEPTH_TEST);
		}
		return old;
	}

	@Override
	public boolean setDepthMask(boolean depthMask) {
		var old = this.depthMask;
		if (old != depthMask) {
			this.depthMask = depthMask;
			glDepthMask(depthMask);
		}
		return old;
	}

	@Override
	public void setDepthOffset(float factor, float units) {
		if (factor != this.depthFactor || units != this.depthUnits) {
			this.depthFactor = factor;
			this.depthUnits = units;
			var depthOffset = factor != 0 || units != 0;
			if (this.depthOffset != depthOffset) {
				this.depthOffset = depthOffset;
				if (depthOffset)
					glEnable(GL_POLYGON_OFFSET_FILL);
				else
					glDisable(GL_POLYGON_OFFSET_FILL);
			}
			if (depthOffset)
				setDepthOffset(factor, units);
		}
	}

	@Override
	public boolean setBlend(boolean blend) {
		var old = this.blend;
		if (old != blend) {
			this.blend = blend;
			if (blend) {
				glEnable(GL_BLEND);
				// glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
			} else
				glDisable(GL_BLEND);
		}
		return old;
	}

	@Override
	public void endRender() {
		glFlush();
	}

	@Override
	public void handleEvent() {
		glfwPollEvents();
	}

	@Override
	public void present() {
		glfwSwapBuffers(window);
		for (int err; (err = glGetError()) != GL_NO_ERROR; )
			Logger.error("glGetError: 0x{}", Integer.toHexString(err));
	}

	@Override
	public void destroy() {
		Arrays.fill(curTexes, 0);
		curTex = 0;
		curProgram = 0;
		var w = window;
		if (w != 0) {
			Callbacks.glfwFreeCallbacks(w);
			glfwDestroyWindow(w);
			window = 0;
		}
//		glfwTerminate();
//		//noinspection EmptyTryBlock
//		try (final var _ = glfwSetErrorCallback(null)) {
//		}
	}

	private static abstract class BaseBuffer implements Buffer {
		final int bo;
		final int glType;
		long dataSize;

		BaseBuffer(int glType, long dataSize) {
			var engine = Engine.current();
			var tmpBuf = engine.getTmpBuf();
			nglGenBuffers(1, tmpBuf.ptr());
			int bo = tmpBuf.getInt();
			if (bo == 0)
				throw new IllegalStateException("nglGenBuffers failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var tmpBuf = Engine.current().getTmpBuf();
					tmpBuf.setInt(bo);
					nglDeleteBuffers(1, tmpBuf.ptr());
				}
			};
			this.bo = bo;
			this.glType = glType;
			this.dataSize = dataSize;
		}

		@Override
		public long dataSize() {
			return dataSize;
		}

		@Override
		public void putData(long offset, long dataPtr, long dataSize) {
			glBindBuffer(glType, bo);
			if (offset + dataSize <= this.dataSize)
				nglBufferSubData(glType, offset, dataSize, dataPtr);
			else if (offset == 0) {
				nglBufferData(glType, dataSize, dataPtr, GL_DYNAMIC_DRAW);
				this.dataSize = dataSize;
			} else
				throw new IllegalStateException("ranged data can not exceed the entire size");
		}
	}

	private static final class VertexBufferGles3 extends BaseBuffer implements VertexBuffer {
		private final @NotNull String format;
		private final int stride;

		VertexBufferGles3(@NotNull String format, int stride, long dataPtr, long dataSize) {
			super(GL_ARRAY_BUFFER, dataSize);
			glBindBuffer(GL_ARRAY_BUFFER, bo);
			nglBufferData(GL_ARRAY_BUFFER, dataSize, dataPtr, dataPtr != 0 ? GL_STATIC_DRAW : GL_DYNAMIC_DRAW);
			this.format = format;
			this.stride = stride;
		}

		int bindFormat(int nextAttribIndex, int divisor) {
			glBindBuffer(GL_ARRAY_BUFFER, bo);
			for (int off = 0, i = 0, e = format.length(); i < e; ) {
				int c = format.charAt(i++), s, t;
				var normalized = (c & 0x20) == 0; // signed:[-1,1] unsigned:[0,1]
				switch (c | 0x20) {
				//@formatter:off
				case 'u':
					//noinspection EnhancedSwitchMigration
					switch (format.charAt(i++) | 0x20) {
					case 'b': s = 1; t = GL_UNSIGNED_BYTE;  break;
					case 's': s = 2; t = GL_UNSIGNED_SHORT; break;
					case 'i': s = 4; t = GL_UNSIGNED_INT;   break;
					default: throw new IllegalArgumentException("invalid format='" + format + "' @ " + (i - 1));
					}
					break;
				case 'b': s = 1; t = GL_BYTE;  break;
				case 's': s = 2; t = GL_SHORT; break;
				case 'i': s = 4; t = GL_INT;   break;
				case 'x': s = 2; t = GL_FIXED; break; // signed 16-bit fixed-point float
				case 'f': s = 4; t = GL_FLOAT; break;
				case 'h': s = 2; t = GLES30.GL_HALF_FLOAT; break;
				case '_' | 0x20: t = 0; s = 1; break;
				default: throw new IllegalArgumentException("invalid format='" + format + "' @ " + (i - 1));
					//@formatter:on
				}
				int n = 0;
				while (i < e && (c = format.charAt(i)) >= '0' && c <= '9') {
					n = n * 10 + c - '0';
					i++;
				}
				if (t != 0 && n > 0) {
					glVertexAttribPointer(nextAttribIndex, n, t, normalized, stride, off);
					glEnableVertexAttribArray(nextAttribIndex);
					if (divisor > 0)
						GLES30.glVertexAttribDivisor(nextAttribIndex, divisor);
					nextAttribIndex++;
				}
				off += s * n;
			}
			return nextAttribIndex;
		}
	}

	@Override
	public @NotNull VertexBuffer createVertexBuffer(@NotNull String format, int stride,
													long vertexDataPtr, long vertexDataSize) {
		return new VertexBufferGles3(format, stride, vertexDataPtr, vertexDataSize);
	}

	private static final class VertexIndexGles3 extends BaseBuffer implements VertexIndex {
		final int depthType;

		VertexIndexGles3(int depth, long dataPtr, long dataSize) {
			super(GL_ELEMENT_ARRAY_BUFFER, dataSize);
			int type = switch (depth) {
				case 1 -> GL_UNSIGNED_BYTE;
				case 2 -> GL_UNSIGNED_SHORT;
				case 4 -> GL_UNSIGNED_INT;
				default -> throw new IllegalArgumentException("invalid depth=" + depth);
			};
			glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, bo);
			nglBufferData(GL_ELEMENT_ARRAY_BUFFER, dataSize, dataPtr, GL_STATIC_DRAW);
			this.depthType = type;
		}

		@Override
		public int depth() {
			return switch (depthType) {
				case GL_UNSIGNED_BYTE -> 1;
				case GL_UNSIGNED_SHORT -> 2;
				case GL_UNSIGNED_INT -> 4;
				default -> throw new IllegalStateException("invalid type=" + depthType);
			};
		}
	}

	@Override
	public @NotNull VertexIndex loadVertexIndex(int depth, long indexDataPtr, long indexDataSize) {
		return new VertexIndexGles3(depth, indexDataPtr, indexDataSize);
	}

	private static final class MeshGles3 implements Mesh {
		private static final int[] glDrawModes = {
				GL_POINTS, // vs:gl_PointSize; fs:gl_PointCoord
				GL_LINES, GL_LINE_LOOP, GL_LINE_STRIP,
				GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN,
		};

		final int vao;
		private final @NotNull VertexBufferGles3 vertexBuffer;
		private final @Nullable VertexBufferGles3 instanceBuffer;
		@Nullable VertexIndexGles3 vertexIndex; // keep ref to avoid crash
		final int glDrawMode;

		MeshGles3(int drawMode, @NotNull VertexBufferGles3 vertexBuffer, @Nullable VertexIndexGles3 vertexIndex,
				  @Nullable VertexBufferGles3 instanceBuffer) {
			var engine = Engine.current();
			var tmpBuf = engine.getTmpBuf();
			GLES30.nglGenVertexArrays(1, tmpBuf.ptr());
			var vao = tmpBuf.getInt();
			if (vao == 0)
				throw new IllegalStateException("nglGenVertexArrays failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var tmpBuf = Engine.current().getTmpBuf();
					tmpBuf.setInt(vao);
					GLES30.nglDeleteVertexArrays(1, tmpBuf.ptr());
				}
			};
			GLES30.glBindVertexArray(vao);
			int nextAttribIndex = vertexBuffer.bindFormat(0, 0);
			if (instanceBuffer != null)
				instanceBuffer.bindFormat(nextAttribIndex, 1);
			glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vertexIndex != null ? vertexIndex.bo : 0);
			GLES30.glBindVertexArray(0);
			this.vao = vao;
			this.vertexBuffer = vertexBuffer;
			this.instanceBuffer = instanceBuffer;
			this.vertexIndex = vertexIndex;
			this.glDrawMode = glDrawModes[drawMode];
		}

		@Override
		public @NotNull VertexBuffer getVertexBuffer() {
			return vertexBuffer;
		}

		@Override
		public @Nullable VertexBuffer getInstanceBuffer() {
			return instanceBuffer;
		}

		@Override
		public @Nullable VertexIndexGles3 getVertexIndex() {
			return vertexIndex;
		}

		@Override
		public void setVertexIndex(@Nullable VertexIndex vertexIndex) {
			var vi = (VertexIndexGles3)vertexIndex;
			GLES30.glBindVertexArray(vao);
			glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vi != null ? vi.bo : 0);
			this.vertexIndex = vi;
		}
	}

	@Override
	public @NotNull Mesh createMesh(int drawMode, @NotNull VertexBuffer vertexBuffer, @Nullable VertexIndex indexData,
									@Nullable VertexBuffer instanceBuffer) {
		return new MeshGles3(drawMode, (VertexBufferGles3)vertexBuffer, (VertexIndexGles3)indexData,
				(VertexBufferGles3)instanceBuffer);
	}

	private static final class UploadPixelBufferGles3 extends BaseBuffer implements UploadPixelBuffer {
		UploadPixelBufferGles3(long dataSize) {
			super(GLES30.GL_PIXEL_UNPACK_BUFFER, dataSize);
			glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, bo);
			nglBufferData(GLES30.GL_PIXEL_UNPACK_BUFFER, dataSize, 0, GL_STREAM_DRAW);
			glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, 0);
		}

		@Override
		public void upload(long offset, long dataSize, boolean invalidLastUploadRange, @NotNull LongConsumer handler) {
			if (dataSize <= 0)
				dataSize = this.dataSize - offset;
			glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, bo);
			var dataPtr = GLES30.nglMapBufferRange(GLES30.GL_PIXEL_UNPACK_BUFFER, offset, dataSize,
					invalidLastUploadRange ? GLES30.GL_MAP_WRITE_BIT | GLES30.GL_MAP_INVALIDATE_RANGE_BIT
							: GLES30.GL_MAP_WRITE_BIT);
			if (dataPtr == 0)
				throw new IllegalStateException("nglMapBufferRange failed: " + glGetError());
			try {
				handler.accept(dataPtr);
			} finally {
				GLES30.glUnmapBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER);
				glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, 0);
			}
		}
	}

	@Override
	public @NotNull UploadPixelBuffer createUploadPixelBuffer(long pixelDataSize) {
		return new UploadPixelBufferGles3(pixelDataSize);
	}

	private static final class DownloadPixelBufferGles3 extends BaseBuffer implements DownloadPixelBuffer {
		DownloadPixelBufferGles3(long dataSize) {
			super(GLES30.GL_PIXEL_PACK_BUFFER, dataSize);
			glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, bo);
			nglBufferData(GLES30.GL_PIXEL_PACK_BUFFER, dataSize, 0, GLES30.GL_STREAM_READ);
			glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);
		}

		@Override
		public void download(long offset, long dataSize, @NotNull LongConsumer handler) {
			if (dataSize <= 0)
				dataSize = this.dataSize - offset;
			glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, bo);
			var dataPtr = GLES30.nglMapBufferRange(GLES30.GL_PIXEL_PACK_BUFFER, offset, dataSize,
					GLES30.GL_MAP_READ_BIT);
			if (dataPtr == 0)
				throw new IllegalStateException("nglMapBufferRange failed: " + glGetError());
			try {
				handler.accept(dataPtr);
			} finally {
				GLES30.glUnmapBuffer(GLES30.GL_PIXEL_PACK_BUFFER);
				glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);
			}
		}
	}

	@Override
	public @NotNull DownloadPixelBuffer createDownloadPixelBuffer(long pixelDataSize) {
		return new DownloadPixelBufferGles3(pixelDataSize);
	}

	void bindTexture(@NotNull Texture2DGles3 tex2d) {
		int tex = tex2d.tex;
		if (curTex != tex) {
			curTex = tex;
			int slot = curSlot;
			tex2d.slot = slot;
			curTexes[slot] = tex;
			glBindTexture(GL_TEXTURE_2D, tex);
		}
	}

	int activeTexture(@Nullable Texture2DGles3 tex2d, int activeFlags) {
		int slot, tex;
		if (tex2d != null) {
			slot = tex2d.slot;
			tex = tex2d.tex;
		} else {
			slot = nilSlot;
			tex = 0;
		}
		if (curTexes[slot] != tex) {
			if (activeFlags == -1)
				throw new IllegalStateException("active too many textures");
			for (slot = curSlot + 1; ((activeFlags >> slot) & 1) != 0; )
				slot++;
			curSlot = slot &= 31;
			if (tex2d != null)
				tex2d.slot = slot;
			else
				nilSlot = slot;
			curTex = tex;
			curTexes[slot] = tex;
			glActiveTexture(GL_TEXTURE0 + slot);
			glBindTexture(GL_TEXTURE_2D, tex);
		}
		return slot;
	}

	private static final class Texture2DGles3 implements Texture2D {
		final int tex;
		private final int width, height, depth;
		int slot;

		Texture2DGles3(int width, int height, int depth, int flags, long texDataPtr) {
			if (depth < 1 || depth > DEPTH_MAX + 1)
				throw new IllegalArgumentException("invalid depth=" + depth);
			var engine = Engine.current();
			var tmpBuf = engine.getTmpBuf();
			nglGenTextures(1, tmpBuf.ptr());
			int tex = tmpBuf.getInt();
			if (tex == 0)
				throw new IllegalStateException("nglGenTextures failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var engine = Engine.current();
					var render = (RenderGles3)engine.getRender();
					var curTexes = render.curTexes;
					for (int i = 0, n = curTexes.length; i < n; i++) {
						if (curTexes[i] == tex)
							curTexes[i] = 0;
					}
					if (render.curTex == tex) {
						render.curTex = 0;
						glBindTexture(GL_TEXTURE_2D, 0);
					}
					var tmpBuf = engine.getTmpBuf();
					tmpBuf.setInt(tex);
					nglDeleteTextures(1, tmpBuf.ptr());
				}
			};
			this.tex = tex;
			this.width = width;
			this.height = height;
			this.depth = depth;
			((RenderGles3)engine.getRender()).bindTexture(this);
			glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, (flags & MIN_NEAREST_FILTER) != 0 ? GL_NEAREST : GL_LINEAR);
			glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, (flags & MAG_NEAREST_FILTER) != 0 ? GL_NEAREST : GL_LINEAR);
			int wrap = (flags & CLAMP_TO_EDGE) != 0 ? GL_CLAMP_TO_EDGE : GL_REPEAT;
			glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, wrap);
			glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, wrap);
			if (depth <= DEPTH_MAX) {
				nglTexImage2D(GL_TEXTURE_2D, 0, depthFormatTypes[depth * 3 - 3], width, height, 0,
						depthFormatTypes[depth * 3 - 2], depthFormatTypes[depth * 3 - 1], texDataPtr);
			} else {
				nglCompressedTexImage2D(GL_TEXTURE_2D, 0, GL42C.GL_COMPRESSED_RGBA_BPTC_UNORM, width, height, 0,
						width * height, texDataPtr);
			}
		}

		@Override
		public int width() {
			return width;
		}

		@Override
		public int height() {
			return height;
		}

		@Override
		public int depth() {
			return depth;
		}

		@Override
		public void putData(int x, int y, int width, int height, long texDataPtr) {
			((RenderGles3)Engine.current().getRender()).bindTexture(this);
			nglTexSubImage2D(GL_TEXTURE_2D, 0, x, y, width, height,
					depthFormatTypes[depth * 3 - 2], depthFormatTypes[depth * 3 - 1], texDataPtr);
		}

		@Override
		public void putData(int x, int y, int width, int height, @NotNull UploadPixelBuffer buffer) {
			((RenderGles3)Engine.current().getRender()).bindTexture(this);
			glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, ((UploadPixelBufferGles3)buffer).bo);
			nglTexSubImage2D(GL_TEXTURE_2D, 0, x, y, width, height,
					depthFormatTypes[depth * 3 - 2], depthFormatTypes[depth * 3 - 1], 0);
			glBindBuffer(GLES30.GL_PIXEL_UNPACK_BUFFER, 0);
		}
	}

	@Override
	public @NotNull Texture2D createTexture2D(int width, int height, int depth, int flags, long texDataPtr) {
		return new Texture2DGles3(width, height, depth, flags, texDataPtr);
	}

	private static final class FrameGles3 implements Frame {
		final int fbo;
		final int[] colorRbos = new int[MAX_COLOR_BUFFERS];
		private int depthRbo;
		final @Nullable Texture2D[] colorTexes = new Texture2D[MAX_COLOR_BUFFERS];
		private @Nullable Texture2D depthTex;
		private int backIndex = -1;

		FrameGles3() {
			var engine = Engine.current();
			var tmpBuf = engine.getTmpBuf();
			nglGenFramebuffers(1, tmpBuf.ptr());
			int fbo = tmpBuf.getInt();
			if (fbo == 0)
				throw new IllegalStateException("nglGenFramebuffers failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var tmpBuf = Engine.current().getTmpBuf();
					tmpBuf.setInt(fbo);
					nglDeleteFramebuffers(1, tmpBuf.ptr());
				}
			};
			this.fbo = fbo;
		}

		@Override
		public void createBuffer(int index, int width, int height, int depth) {
			if (!(index == -1 && depthRbo == 0 ||
				  (index & 0xffff_ffffL) < MAX_COLOR_BUFFERS && colorRbos[index] == 0))
				throw new IllegalStateException("already bound buffer: index=" + index);
			if (depth < 1 || depth > DEPTH_MAX)
				throw new IllegalArgumentException("invalid depth=" + depth);
			var engine = Engine.current();
			var tmpBuf = engine.getTmpBuf();
			nglGenRenderbuffers(1, tmpBuf.ptr());
			int rbo = tmpBuf.getInt();
			if (rbo == 0)
				throw new IllegalStateException("nglGenRenderbuffers failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var tmpBuf0 = Engine.current().getTmpBuf();
					tmpBuf0.setInt(rbo);
					nglDeleteRenderbuffers(1, tmpBuf0.ptr());
				}
			};
			glBindRenderbuffer(GL_RENDERBUFFER, rbo);
			glRenderbufferStorage(GL_RENDERBUFFER, depthFormatTypes[depth * 3 - 3], width, height);
			glBindFramebuffer(GL_FRAMEBUFFER, fbo);
			glFramebufferRenderbuffer(GL_FRAMEBUFFER,
					index >= 0 ? GL_COLOR_ATTACHMENT0 + index : GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, rbo);
			int status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
			if (status != GL_FRAMEBUFFER_COMPLETE)
				throw new IllegalStateException("glCheckFramebufferStatus failed: " + status);
			if (index >= 0) {
				colorRbos[index] = rbo;
				colorTexes[index] = null;
			} else {
				depthRbo = rbo;
				depthTex = null;
			}
		}

		@Override
		public void bindTexture(int index, @Nullable Texture2D tex) {
			if (!(index == -1 && depthRbo == 0 ||
				  (index & 0xffff_ffffL) < MAX_COLOR_BUFFERS && colorRbos[index] == 0))
				throw new IllegalStateException("already bound buffer: index=" + index);
			glBindFramebuffer(GL_FRAMEBUFFER, fbo);
			glFramebufferTexture2D(GL_FRAMEBUFFER, index >= 0 ? GL_COLOR_ATTACHMENT0 + index : GL_DEPTH_ATTACHMENT,
					GL_TEXTURE_2D, tex != null ? ((Texture2DGles3)tex).tex : 0, 0);
			int status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
			if (status != GL_FRAMEBUFFER_COMPLETE)
				throw new IllegalStateException("glCheckFramebufferStatus failed: " + status);
			if (index >= 0)
				colorTexes[index] = tex;
			else
				depthTex = tex;
		}

		@Override
		public @Nullable Texture2D getTexture(int index) {
			return index >= 0 ? colorTexes[index] : depthTex;
		}

		@Override
		public void setBackBufferIndex(int index) {
			backIndex = index;
		}

		@Override
		public int getBackBufferIndex() {
			return backIndex;
		}

		@Override
		public void use() {
			var tmpBuf = Engine.current().getTmpBuf();
			for (int i = 0; i < MAX_COLOR_BUFFERS; i++)
				tmpBuf.setInt(i, colorRbos[i] != 0 || colorTexes[i] != null ? GL_COLOR_ATTACHMENT0 + i : GL_NONE);
			tmpBuf.setInt(MAX_COLOR_BUFFERS, GL_NONE);
			if ((backIndex & 0xffff_ffffL) <= MAX_COLOR_BUFFERS)
				tmpBuf.setInt(backIndex, GL_BACK);
			int n = MAX_COLOR_BUFFERS + 1;
			for (; n > 0; n--) {
				if (tmpBuf.getInt(n - 1) != GL_NONE)
					break;
			}
			glBindFramebuffer(GL_FRAMEBUFFER, fbo);
			GLES30.nglDrawBuffers(n, tmpBuf.ptr());
		}

		@Override
		public void clear(int index, float red, float green, float blue, float alpha, float depth) {
			var tmpBuf = Engine.current().getTmpBuf();
			if ((index & 0xffff_ffffL) < Frame.MAX_COLOR_BUFFERS) {
				tmpBuf.setFloat(0, red);
				tmpBuf.setFloat(1, green);
				tmpBuf.setFloat(2, blue);
				tmpBuf.setFloat(3, alpha);
				GLES30.nglClearBufferfv(GLES30.GL_COLOR, index, tmpBuf.ptr());
			}
			if (depth >= 0 && depth <= 1) {
				tmpBuf.setFloat(0, depth);
				GLES30.nglClearBufferfv(GLES30.GL_DEPTH, 0, tmpBuf.ptr());
			}
		}

		@Override
		public void unuse() {
			glBindFramebuffer(GL_FRAMEBUFFER, 0);
		}
	}

	@Override
	public @NotNull Frame createFrame() {
		return new FrameGles3();
	}

	@Override
	public void copyFrame(@Nullable Frame srcFrame, @Nullable Frame dstFrame, int srcIndex, int dstIndex, int flags,
						  int srcX, int srcY, int srcWidth, int srcHeight,
						  int dstX, int dstY, int dstWidth, int dstHeight) {
		var engine = Engine.current();
		var sf = (FrameGles3)srcFrame;
		var df = (FrameGles3)dstFrame;
		try {
			if (sf != null)
				glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, sf.fbo);
			if (df != null)
				glBindFramebuffer(GLES30.GL_DRAW_FRAMEBUFFER, df.fbo);
			int mask = 0;
			if ((sf == null || (srcIndex & 0xffff_ffffL) < Frame.MAX_COLOR_BUFFERS
							   && (sf.colorRbos[srcIndex] != 0 || sf.colorTexes[srcIndex] != null)) &&
				(df == null || (dstIndex & 0xffff_ffffL) < Frame.MAX_COLOR_BUFFERS
							   && (df.colorRbos[dstIndex] != 0 || df.colorTexes[dstIndex] != null))) {
				GLES30.glReadBuffer(sf == null ? GL_BACK : GL_COLOR_ATTACHMENT0 + srcIndex);
				var tmpBuf = engine.getTmpBuf();
				tmpBuf.setInt(df == null ? GL_BACK : GL_COLOR_ATTACHMENT0 + dstIndex);
				GLES30.nglDrawBuffers(1, tmpBuf.ptr());
				mask = GL_COLOR_BUFFER_BIT;
			}
			if ((flags & COPY_DEPTH) != 0)
				mask |= GL_DEPTH_BUFFER_BIT;
			if (mask != 0) {
				GLES30.glBlitFramebuffer(
						srcX, srcY, srcX + srcWidth, srcY + srcHeight,
						dstX, dstY, dstX + dstWidth, dstY + dstHeight,
						mask, (flags & NEAREST_FILTER) != 0 ? GL_NEAREST : GL_LINEAR);
			}
		} finally {
			if (sf != null || df != null) {
				var camera = engine.getCurCamera();
				if (camera != null) {
					var lastFrame = camera.getCurRenderFrame();
					if (lastFrame != null)
						lastFrame.use();
				}
			}
		}
	}

	@Override
	public void readFrame(@Nullable Frame frame, int index, int depth, int x, int y, int width, int height, long dataPtr) {
		if (frame != null && ((index & 0xffff_ffffL) >= Frame.MAX_COLOR_BUFFERS || index == -1))
			throw new IllegalArgumentException("invalid index=" + index);
		if (depth < 1 || depth > DEPTH_MAX)
			throw new IllegalArgumentException("invalid depth=" + depth);
		var f = (FrameGles3)frame;
		try {
			if (f != null)
				glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, f.fbo);
			if (index >= 0 || f == null)
				GLES30.glReadBuffer(f == null ? GL_BACK : GL_COLOR_ATTACHMENT0 + index);
			nglReadPixels(x, y, width, height, depthFormatTypes[depth * 3 - 2], depthFormatTypes[depth * 3 - 1], dataPtr);
		} finally {
			if (f != null) {
				var camera = Engine.current().getCurCamera();
				if (camera != null) {
					var lastFrame = camera.getCurRenderFrame();
					if (lastFrame != null)
						lastFrame.use();
				}
			}
		}
	}

	@Override
	public void readFrame(@Nullable Frame frame, int index, int depth, int x, int y, int width, int height,
						  @NotNull DownloadPixelBuffer buffer) {
		if (frame != null && ((index & 0xffff_ffffL) >= Frame.MAX_COLOR_BUFFERS || index == -1))
			throw new IllegalArgumentException("invalid index=" + index);
		if (depth < 1 || depth > DEPTH_MAX)
			throw new IllegalArgumentException("invalid depth=" + depth);
		var f = (FrameGles3)frame;
		try {
			if (f != null)
				glBindFramebuffer(GLES30.GL_READ_FRAMEBUFFER, f.fbo);
			if (index >= 0 || f == null)
				GLES30.glReadBuffer(f == null ? GL_BACK : GL_COLOR_ATTACHMENT0 + index);
			glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, ((DownloadPixelBufferGles3)buffer).bo);
			nglReadPixels(x, y, width, height, depthFormatTypes[depth * 3 - 2], depthFormatTypes[depth * 3 - 1], 0);
		} finally {
			glBindBuffer(GLES30.GL_PIXEL_PACK_BUFFER, 0);
			if (f != null) {
				var camera = Engine.current().getCurCamera();
				if (camera != null) {
					var lastFrame = camera.getCurRenderFrame();
					if (lastFrame != null)
						lastFrame.use();
				}
			}
		}
	}

	private static final class ShaderGles3 implements Shader {
		final int shader;
		private final int type;

		ShaderGles3(int type, long shaderTextPtr, long shaderTextSize) {
			var engine = Engine.current();
			int shader = glCreateShader(type == Shader.VERTEX ? GL_VERTEX_SHADER : GL_FRAGMENT_SHADER);
			if (shader == 0)
				throw new IllegalStateException("glCreateShader failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					glDeleteShader(shader);
				}
			};
			var tmpBuf = engine.getTmpBuf();
			tmpBuf.setLong(shaderTextPtr);
			tmpBuf.setInt(2, (int)shaderTextSize);
			nglShaderSource(shader, 1, tmpBuf.ptr(), tmpBuf.ptr(8));
			glCompileShader(shader);
			nglGetShaderiv(shader, GL_COMPILE_STATUS, tmpBuf.ptr());
			if (tmpBuf.getInt() == 0)
				throw new IllegalStateException("glCompileShader(" + type + ") failed: " + glGetShaderInfoLog(shader));
			this.shader = shader;
			this.type = type;
		}

		@Override
		public int type() {
			return type;
		}
	}

	@Override
	public @NotNull Shader compileShader(int type, long shaderTextPtr, long shaderTextSize) {
		return new ShaderGles3(type, shaderTextPtr, shaderTextSize);
	}

	private static final class ShaderBufferGles3 extends BaseBuffer implements ShaderBuffer { // layout(std140) uniform ... { ...; ...; };
		long curOffset;
		long curSize;

		ShaderBufferGles3(long dataPtr, long dataSize) {
			super(GLES30.GL_UNIFORM_BUFFER, dataSize);
			glBindBuffer(GLES30.GL_UNIFORM_BUFFER, bo);
			nglBufferData(GLES30.GL_UNIFORM_BUFFER, dataSize, dataPtr, GL_DYNAMIC_DRAW);
			curSize = dataSize;
		}

		@Override
		public void setDataRange(long offset, long size) {
			curOffset = offset;
			curSize = size;
		}
	}

	@Override
	public @NotNull ShaderBuffer createShaderBuffer(long shaderDataPtr, long shaderDataSize) {
		return new ShaderBufferGles3(shaderDataPtr, shaderDataSize);
	}

	private static final class ProgramGles3 implements Program {
		static final int TYPE_INT = 0;
		static final int TYPE_FLOAT = 1;
		static final int TYPE_VEC4 = 2;
		static final int TYPE_MAT4 = 3;
		static final int TYPE_TEX = 4;
		static final int TYPE_UBO = 5;
		private static final @NotNull Map<Class<?>, Integer> typeMap;

		static {
			typeMap = Map.of(
					int.class, TYPE_INT,
					float.class, TYPE_FLOAT,
					Vec4.class, TYPE_VEC4,
					Mat4.class, TYPE_MAT4,
					Texture2D.class, TYPE_TEX,
					ShaderBuffer.class, TYPE_UBO);
		}

		final int program;
		final @NotNull Class<? extends Model> modelClass;
		final int @NotNull [] uniforms; // {type,uniformLocation/bindingPoint,fieldOffset}

		ProgramGles3(@NotNull Shader vsShader, @NotNull Shader fsShader,
					 @NotNull Class<? extends Model> modelClass, int @NotNull [] uniformsBuf) {
			var engine = Engine.current();
			int program = glCreateProgram();
			if (program == 0)
				throw new IllegalStateException("glCreateProgram failed: " + glGetError());
			new Cleaner.Node(this, engine.getCleaner()) {
				@Override
				protected void clean() {
					var render = (RenderGles3)Engine.current().getRender();
					if (render.curProgram == program)
						render.curProgram = 0;
					glDeleteProgram(program);
				}
			};
			glAttachShader(program, ((ShaderGles3)vsShader).shader);
			glAttachShader(program, ((ShaderGles3)fsShader).shader);
			glLinkProgram(program);
			var tmpBuf = engine.getTmpBuf();
			nglGetProgramiv(program, GL_LINK_STATUS, tmpBuf.ptr());
			if (tmpBuf.getInt() == 0)
				throw new AssertionError("glLinkProgram failed: " + glGetProgramInfoLog(program));
			int i = 0, j = 0, tn = 0;
			for (var field : modelClass.getFields()) {
				if ((field.getModifiers() & (Modifier.STATIC | Modifier.TRANSIENT)) == 0) {
					var name = field.getName();
					var typeId = typeMap.get(field.getType());
					if (typeId != null) {
						int loc = -1;
						if (typeId == TYPE_UBO) {
							int idx = GLES30.glGetUniformBlockIndex(program, name);
							if (idx != GLES30.GL_INVALID_INDEX) {
								loc = j++;
								GLES30.glUniformBlockBinding(program, idx, loc);
							} else
								Logger.warn("RenderGles3.ProgramGles3: glGetUniformBlockIndex({}) failed", name);
						} else {
							loc = glGetUniformLocation(program, name);
							if (loc == -1)
								Logger.warn("RenderGles3.ProgramGles3: glGetUniformLocation({}) failed", name);
							else if (typeId == TYPE_TEX && ++tn > 32) {
								Logger.warn("RenderGles3.ProgramGles3: too many texture field, ignore: {}", name);
								loc = -1;
							}
						}
						if (loc != -1) {
							uniformsBuf[i++] = typeId;
							uniformsBuf[i++] = loc;
							uniformsBuf[i++] = (int)UnsafeUtil.objectFieldOffset(field);
						}
					} else
						Logger.warn("RenderGles3.ProgramGles3: unsupported type of field: {}", name);
				}
			}
			this.program = program;
			this.modelClass = modelClass;
			uniforms = Arrays.copyOf(uniformsBuf, i);
		}

		@Override
		public @NotNull Class<? extends Model> getModelClass() {
			return modelClass;
		}
	}

	@Override
	public @NotNull Program linkShader(@NotNull Shader vsShader, @NotNull Shader fsShader,
									   @NotNull Class<? extends Model> bindModelClass) {
		return new ProgramGles3(vsShader, fsShader, bindModelClass, uniformsBuf);
	}

	@Override
	public void draw(@NotNull Model model) {
		int vertexCount = model._count;
		MeshGles3 mesh;
		ProgramGles3 program;
		if (vertexCount <= 0 || (mesh = (MeshGles3)model._mesh) == null
			|| (program = (ProgramGles3)model._program) == null)
			return;
		if (model.getClass() != program.modelClass)
			throw new IllegalArgumentException("model(" + model.getClass() + ") is not " + program.modelClass);
		int p = program.program;
		if (curProgram != p) {
			curProgram = p;
			glUseProgram(p);
		}
		var uniforms = program.uniforms;
		for (int activeFlags = 0, i = 0, n = uniforms.length; i < n; i += 3) {
			switch (uniforms[i]) {
			case ProgramGles3.TYPE_INT:
				glUniform1i(uniforms[i + 1], UnsafeUtil.getInt(model, uniforms[i + 2]));
				break;
			case ProgramGles3.TYPE_FLOAT:
				glUniform1f(uniforms[i + 1], UnsafeUtil.getFloat(model, uniforms[i + 2]));
				break;
			case ProgramGles3.TYPE_VEC4:
				var vec4 = (Vec4)UnsafeUtil.getObj(model, uniforms[i + 2]);
				if (vec4 != null)
					nglUniform4fv(uniforms[i + 1], 1, vec4.ptr);
				break;
			case ProgramGles3.TYPE_MAT4:
				var mat4 = (Mat4)UnsafeUtil.getObj(model, uniforms[i + 2]);
				if (mat4 != null)
					nglUniformMatrix4fv(uniforms[i + 1], 1, true, mat4.ptr);
				break;
			case ProgramGles3.TYPE_TEX:
				var tex2d = (Texture2DGles3)UnsafeUtil.getObj(model, uniforms[i + 2]);
				int slot = ((RenderGles3)Engine.current().getRender()).activeTexture(tex2d, activeFlags);
				activeFlags |= 1 << slot;
				glUniform1i(uniforms[i + 1], slot);
				break;
			case ProgramGles3.TYPE_UBO:
				var ubo = (ShaderBufferGles3)UnsafeUtil.getObj(model, uniforms[i + 2]);
				if (ubo != null) {
					GLES30.glBindBufferRange(GLES30.GL_UNIFORM_BUFFER, uniforms[i + 1],
							ubo.bo, ubo.curOffset, ubo.curSize);
				}
				break;
			}
		}
		GLES30.glBindVertexArray(mesh.vao);
		int drawMode = mesh.glDrawMode;
		var vi = mesh.vertexIndex;
		int index = model._index;
		int instance = model._instance;
		if (instance <= 1) {
			if (vi != null)
				nglDrawElements(drawMode, vertexCount, vi.depthType, index);
			else
				glDrawArrays(drawMode, index, vertexCount);
		} else { // vs:gl_InstanceID
			if (vi != null)
				GLES30.nglDrawElementsInstanced(drawMode, vertexCount, vi.depthType, index, instance);
			else
				GLES30.glDrawArraysInstanced(drawMode, index, vertexCount, instance);
		}
	}
}
